20장. 패키지와 모듈
지금까지는 거의 모든 코드를
main.go 한 파일에 몰아 적었다.
작은 예제일 땐 그래도 괜찮지만,
프로젝트가 조금만 커져도 금세 한계가 온다.
코드를 여러 파일과 디렉터리로 나누고, 나눈 것끼리 잘 연결하는 도구가 필요하다. Go 에서는 패키지(package) 와 모듈(module) 이 이 역할을 맡는다.
이 장의 목표는 다음과 같다.
- 패키지가 왜 필요한지 이해하기
- 직접 패키지를 만들고 가져다 써 보기
- 대문자/소문자 한 글자로 공개 범위가 결정되는 규칙 익히기
- 모듈,
go.mod,go.sum의 역할 알기 - 외부 패키지를 받아 쓰는 절차 익히기
20.1 패키지가 왜 필요한가
지금 우리가 짠 프로그램들이 다음 셋 중 하나라도 겪는다면 패키지가 필요하다.
- 파일 하나가 너무 길어졌다
- 같은 코드를 여러 프로그램에서 쓰고 싶다
- 함수 이름이 서로 충돌한다
코드를 정리한다
한 파일에 함수 50개가 들어 있으면, 어디서 무엇을 하는지 찾기 힘들다.
user 관련 코드는 user 패키지에,
order 관련 코드는 order 패키지에 둔다.
이렇게 영역을 나누면 코드의 지도가 생긴다.
코드를 재사용한다
한번 잘 만든 greetings 패키지를
이 프로그램에서도, 저 프로그램에서도 쓸 수 있다.
복사-붙여넣기 대신 임포트만 하면 된다.
이름 충돌을 피한다
세상에는 Parse 라는 함수가 수도 없이 많다.
json.Parsexml.Parseurl.Parse
패키지가 이름 앞에 점 하나로 붙어
누구의 Parse 인지 구분해 준다.
패키지는 “코드를 담는 폴더 + 이름표“라고 생각하면 편하다.
20.2 패키지 만들기
규칙: 디렉터리가 곧 패키지
Go 의 규칙은 단순하다.
- 한 디렉터리 = 한 패키지
- 같은 디렉터리의
.go파일은 모두 같은 패키지에 속해야 한다 - 디렉터리가 다르면 패키지도 다르다
패키지 이름은 보통 디렉터리 이름과 똑같이 짓는다. 꼭 그래야 하는 건 아니지만, 다르게 지으면 혼란만 커진다.
예제: greetings 패키지
작은 프로젝트를 하나 만들어 본다.
mkdir myapp
cd myapp
go mod init example.com/myapp
mkdir greetings
디렉터리 구조는 이렇게 된다.
myapp/
├── go.mod
└── greetings/
이제 greetings/hello.go 를 만든다.
package greetings
import "fmt"
func Hello(name string) string {
return fmt.Sprintf("Hello, %s!", name)
}
첫 줄의 package greetings 가 중요하다.
이 파일은 greetings 패키지에 속한다고
컴파일러에게 알린다.
같은 패키지에 파일 더 추가하기
같은 디렉터리에 greetings/bye.go 를 만들어 보자.
package greetings
import "fmt"
func Bye(name string) string {
return fmt.Sprintf("Goodbye, %s!", name)
}
두 파일 모두 package greetings 다.
이러면 Hello 와 Bye 는
같은 패키지에 속한 두 함수가 된다.
만약 한쪽에서 package greetings 라 적고
다른 쪽에서 package farewell 이라 적으면,
found packages greetings and farewell in /myapp/greetings
컴파일러가 바로 거부한다.
20.3 import 와 사용
한 모듈 안에서 다른 패키지 쓰기
방금 만든 greetings 를
main.go 에서 가져다 써 보자.
myapp/main.go:
package main
import (
"fmt"
"example.com/myapp/greetings"
)
func main() {
msg := greetings.Hello("Alice")
fmt.Println(msg)
}
이제 디렉터리는 이런 모습이다.
myapp/
├── go.mod
├── main.go
└── greetings/
└── hello.go
실행해 본다.
go run .
출력:
Hello, Alice!
import 경로의 정체
가져올 때 적은 경로를 보자.
import "example.com/myapp/greetings"
이 경로는 두 조각으로 이루어져 있다.
| 부분 | 의미 |
|---|---|
example.com/myapp | 모듈 이름 (go.mod 의 module) |
/greetings | 모듈 안의 디렉터리 경로 |
즉 import 경로는 모듈 이름 + 디렉터리 경로 다. 폴더 트리를 그대로 따라간다.
임포트한 패키지 부르기
임포트한 뒤에는 패키지 이름.식별자 로 접근한다.
greetings.Hello("Alice")
여기서 앞의 greetings 는
import 경로의 마지막 조각이자
파일 안의 package greetings 다.
보통 둘이 일치한다.
import 별칭
길거나 충돌이 날 땐 별칭을 쓸 수 있다.
import g "example.com/myapp/greetings"
func main() {
fmt.Println(g.Hello("Alice"))
}
별칭은 가끔 필요할 때만 쓰고, 평소엔 원래 이름을 그대로 쓰는 게 좋다.
20.4 대문자/소문자 export 규칙
Go 에는 public 이나 private 같은 키워드가 없다.
대신 이름의 첫 글자가 그 역할을 한다.
대문자로 시작하면 외부 공개, 소문자로 시작하면 패키지 내부 전용.
이 규칙이 함수, 타입, 필드, 변수, 상수 모두에 똑같이 적용된다.
함수 예제
greetings/hello.go:
package greetings
import "fmt"
// 외부에서 쓸 수 있음 (대문자)
func Hello(name string) string {
return format(name, "Hello")
}
// 패키지 내부 전용 (소문자)
func format(name, word string) string {
return fmt.Sprintf("%s, %s!", word, name)
}
main.go 에서 호출해 보자.
greetings.Hello("Alice") // OK
greetings.format("A", "B") // 컴파일 에러
두 번째 줄은 다음과 같은 에러가 난다.
cannot refer to unexported name greetings.format
구조체 필드도 똑같다
package user
type User struct {
Name string // 외부 접근 가능
age int // 같은 패키지 안에서만 보임
}
다른 패키지에서 User 를 쓰면,
u := user.User{Name: "Alice"}
fmt.Println(u.Name) // OK
fmt.Println(u.age) // 컴파일 에러
age 는 보이지도 않는다.
한눈에 정리
| 첫 글자 | 의미 | 예시 |
|---|---|---|
| 대문자 | exported (공개) | Hello, Name |
| 소문자 | unexported (내부) | format, age |
가독성을 위해서가 아니다. 컴파일러가 진짜로 첫 글자를 본다.
20.5 모듈이란
패키지가 코드를 담는 폴더라면, 모듈 은 그 폴더들을 한 단위로 묶은 큰 상자다.
모듈의 정의
모듈은 다음 세 가지를 한꺼번에 가지는 묶음이다.
- 여러 개의 패키지
- 하나의 버전 (예: v1.2.3)
- 외부 의존성 목록
모듈 하나는 보통 하나의 git 저장소에 대응한다. GitHub 리포지토리 하나가 모듈 하나라고 보면 편하다.
go.mod 가 모듈을 정의한다
디렉터리 어딘가에 go.mod 파일이 있으면
거기서부터가 한 모듈이다.
myapp/ <- 여기가 모듈 루트
├── go.mod
├── main.go
├── greetings/ <- 패키지
└── user/ <- 패키지
go mod init 명령으로 만들었던 그 파일이다.
패키지와 모듈의 관계
이 둘은 다른 층위의 개념이다.
| 개념 | 단위 | 비유 |
|---|---|---|
| 패키지 | 디렉터리 하나 | 책의 한 챕터 |
| 모듈 | 여러 패키지의 묶음 | 책 한 권 |
작은 프로젝트는 모듈 하나에
패키지도 하나(main)만 있을 수도 있다.
큰 프로젝트는 모듈 하나에
패키지 수십 개가 들어 있을 수도 있다.
20.6 go.mod 와 go.sum
go.mod 의 구조
go mod init example.com/myapp 으로 만든 파일이다.
module example.com/myapp
go 1.22
처음엔 단 두 줄이다. 외부 패키지를 받기 시작하면 점점 늘어난다.
module example.com/myapp
go 1.22
require (
github.com/google/uuid v1.6.0
github.com/stretchr/testify v1.9.0
)
각 줄의 역할:
| 줄 | 역할 |
|---|---|
module ... | 이 모듈의 이름 (import 경로의 뿌리) |
go ... | 어떤 Go 언어 버전을 가정하는지 |
require ... | 어떤 외부 모듈을 어느 버전으로 쓰는지 |
go.sum 의 역할
go.sum 은 외부 모듈을 처음 받을 때 함께 생긴다.
안을 열어 보면 알 수 없는 해시값이 줄줄이 적혀 있다.
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
이 파일의 역할은 두 가지다.
- 보안 — 다운로드한 의존성이 변조되지 않았는지 검증
- 재현성 — 다른 컴퓨터에서도 정확히 같은 코드를 받게 함
손으로 편집하지 않는다
go.mod 와 go.sum 모두
직접 텍스트 에디터로 고치는 일은 거의 없다.
다음 명령들이 알아서 갱신해 준다.
go get github.com/... # 의존성 추가
go mod tidy # 안 쓰는 것 정리, 누락된 것 추가
go mod download # 의존성 미리 받아두기
go.mod와go.sum은 둘 다 git 에 커밋한다. 빠지면 다른 사람이 같은 빌드를 만들 수 없다.
20.7 외부 패키지 사용 (go get)
표준 라이브러리만으로는 부족할 때가 있다. 그때는 다른 사람이 만든 패키지를 가져다 쓴다.
패키지 추가
예를 들어 UUID(고유 식별자) 생성 라이브러리를 써 보자.
go get github.com/google/uuid
이 한 줄로 일어나는 일:
- 인터넷에서 해당 모듈 최신 버전을 받음
go.mod의require에 항목 추가go.sum에 해시 추가- 로컬 캐시(
$GOPATH/pkg/mod)에 저장
코드에서 쓰기
package main
import (
"fmt"
"github.com/google/uuid"
)
func main() {
id := uuid.New()
fmt.Println(id)
}
import 경로가 곧 GitHub 주소다.
Go 의 모듈 시스템은
“import 경로 = 가져올 위치” 라는 단순한 규칙을 따른다.
특정 버전 지정
버전을 직접 지정하고 싶으면 @ 뒤에 적는다.
go get github.com/google/uuid@v1.6.0
go get github.com/google/uuid@latest
정리하기: go mod tidy
코드를 짜다 보면
go.mod 가 실제 코드와 어긋날 때가 있다.
- 코드는 안 쓰는데
require에 남아 있거나 - 코드는 쓰는데
require가 누락되었거나
이럴 땐 한 줄이면 된다.
go mod tidy
소스 코드를 훑어서 필요한 건 추가하고 안 쓰는 건 지운다. 새 외부 패키지를 import 한 다음 습관적으로 한 번 돌려 주면 좋다.
자주 쓰는 명령 정리
| 명령 | 역할 |
|---|---|
go mod init <name> | 새 모듈 만들기 |
go get <path> | 의존성 추가 / 업데이트 |
go mod tidy | go.mod 와 실제 코드 일치시키기 |
go mod download | 의존성 미리 받기 |
go list -m all | 의존성 목록 보기 |
20.8 init 함수
패키지가 처음 사용될 때 딱 한 번만 실행되는 특별한 함수가 있다.
기본 사용
package config
import "fmt"
var Settings map[string]string
func init() {
Settings = make(map[string]string)
Settings["env"] = "development"
fmt.Println("config 패키지 초기화")
}
init 함수의 규칙:
- 매개변수도 반환값도 없다
- 직접 호출할 수 없다
- 패키지가 로드될 때 Go 런타임이 알아서 부른다
- 한 패키지 안에 여러 개를 둘 수 있다
호출 순서
여러 init 함수가 있을 때의 순서는 정해져 있다.
- 임포트한 패키지의
init이 먼저 다 끝남 - 그다음 현재 패키지의 패키지 수준 변수가 초기화됨
- 그다음 같은 패키지 안의
init들이 차례로 실행됨 - 마지막으로
main.main이 호출됨
같은 패키지 안에 여러 파일이 있고
각 파일에 init 이 있다면,
파일 이름 순서대로 실행된다.
신중히 쓰라
init 은 편리해 보이지만 함정도 많다.
- 어디서 무엇이 실행되는지 추적이 어려워진다
- 테스트할 때 부수 효과가 자꾸 따라온다
- 임포트만 했는데 실행이 일어난다
가능하면
init대신 명시적으로 호출되는Setup()/New()함수를 쓰자.
init 의 좋은 용도는 한정적이다.
- 미리 계산해 두는 룩업 테이블 만들기
- 정규표현식 컴파일 결과 캐싱
- 표준 라이브러리에 자기 자신 등록하기 (예: 이미지 디코더, DB 드라이버)
20.9 internal 패키지
대문자 한 글자만으로 공개 여부를 가르는 방식은 단순하지만, 가끔 더 강한 캡슐화가 필요하다.
“이 패키지는 우리 모듈 안에서만 쓰고, 바깥 모듈은 절대 쓰지 못하게 막고 싶다.”
Go 는 이를 위해 internal 디렉터리 규칙을 둔다.
규칙
경로 어딘가에 internal 이라는 디렉터리가 있으면,
그 안의 패키지는 다음 영역에서만 임포트할 수 있다.
internal의 부모 디렉터리, 그리고 그 아래의 모든 코드.
밖에서는 import 자체가 거부된다.
예제
myapp/
├── go.mod
├── main.go
├── api/
│ └── handler.go
└── internal/
└── db/
└── db.go
이 구조에서
main.go,api/handler.go는internal/db를 임포트할 수 있다myapp바깥의 다른 모듈은example.com/myapp/internal/db를 임포트하면 컴파일 에러가 난다
언제 쓰는가
- 라이브러리 작성자가 “이건 구현 세부사항이니 손대지 마세요“라고 선언할 때
- 모듈 내부에서만 공유하는 헬퍼 패키지를 둘 때
오픈 소스 라이브러리들에 자주 보인다.
표준 라이브러리에도 곳곳에 internal 이 들어 있다.
20.10 정리
이 장에서 살펴본 내용:
- 패키지는 같은 디렉터리에 모인
.go파일들의 묶음이다 - import 경로는 모듈 이름 + 디렉터리 경로다
- 이름의 첫 글자가 대문자면 외부 공개, 소문자면 패키지 내부 전용이다
- 모듈은 여러 패키지를 묶은 단위이며
go.mod가 그 정의를 담는다 go.sum은 의존성 해시로 보안과 재현성을 보장한다- 외부 패키지는
go get으로 받고go mod tidy로 정리한다 init함수는 패키지 초기화에 쓰이지만 신중히 쓴다internal디렉터리로 강한 캡슐화를 만들 수 있다
이로써 코드를 어떻게 나누고 어떻게 모아 쓸지 도구를 다 갖췄다.
다음 장에서는 또 다른 큰 주제, “에러를 어떻게 다룰 것인가“를 살펴본다. Go 는 다른 언어와는 사뭇 다른 길을 택했는데, 그 사고방식을 차근차근 풀어 본다.